package com.googlecode.xpathproxy;

import com.googlecode.xpathproxy.conversion.Converter;
import org.apache.log4j.Logger;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

import javax.xml.namespace.NamespaceContext;
import javax.xml.namespace.QName;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;
import javax.xml.xpath.XPathFactoryConfigurationException;
import javax.xml.xpath.XPathVariableResolver;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;

/**
 * @author Ray Krueger
 */
public class XPathMethodInvocationHandler implements InvocationHandler {

    private static final Logger log = Logger.getLogger(XPathMethodInvocationHandler.class);

    private final Node element;
    private final Converter[] converters;
    private final NamespaceContext namespaceContext;

    @SuppressWarnings("unchecked")
    public XPathMethodInvocationHandler(Class targetInterface, Node element, Converter[] converters) {

        this.converters = (converters == null) ? new Converter[0] : converters;
        this.element = element;
        namespaceContext = buildNamespaceContext(targetInterface);

        for (Method method : targetInterface.getMethods()) {
            if (!method.isAnnotationPresent(XPathMethod.class)) {
                throw new IllegalArgumentException("@XPathMethod annotation not found on method [" + method + "]");
            }
        }
    }

    public Object invoke(Object proxy, Method method, final Object[] args) throws Throwable {
        log.debug("Invoker called for method [" + method + "]");

        String expression = getExpressionForMethod(method);

        XPath xpath = createXPath();
        if (args != null && args.length > 0) {
            registerVariableResolver(xpath, args);
        }

        Class returnType = method.getReturnType();
        Converter<?> converter = findConverter(returnType);

        if (!isSupportedXPathType(returnType) && converter == null) {
            throw new IllegalArgumentException("A converter is required but not registered for method return type [" + returnType + "]. " +
                    "The offending method is [" + method + "].");
        }

        boolean conversionRequired = converter != null;

        QName qname;
        if (conversionRequired) {
            qname = converter.getExpectedInput();
        } else {
            qname = determineExpectedQName(method.getReturnType());
        }

        Object returnValue = evaluateXPath(xpath, expression, qname);
        if (conversionRequired) {
            returnValue = convert(converter, (String) returnValue);
        }

        return returnValue;
    }

    protected Object evaluateXPath(XPath xpath, String expression, QName qname) throws XPathExpressionException {
        return xpath.evaluate(expression, element, qname);
    }

    protected String getExpressionForMethod(Method method) {
        XPathMethod annotation = method.getAnnotation(XPathMethod.class);
        return annotation.value();
    }

    protected void registerVariableResolver(XPath xpath, final Object[] args) {

        XPathVariableResolver variableResolver = new XPathVariableResolver() {
            public Object resolveVariable(QName qname) {
                for (int i = 0; i < args.length; i++) {
                    Object arg = args[i];
                    if (String.valueOf(i).equals(qname.getLocalPart())) {
                        return arg;
                    }
                }
                return null;
            }
        };

        xpath.setXPathVariableResolver(variableResolver);
    }

    /**
     * Find the proper converter for the given type.
     * Uses caches results to avoid multiple iterations over the converter array.
     *
     * @param cls the type we'd like a converter for.
     * @return Converter found or null
     */
    protected Converter<?> findConverter(Class<?> cls) {

        log.debug("Finding converter for type [" + cls.getName() + "]");
        for (Converter<?> converter : converters) {
            if (converter.supportsOutputType(cls)) {
                log.debug("Found converter [" + converter + "] for type [" + cls.getName() + "]");
                return converter;
            }
        }

        log.debug("No converter found for type [" + cls.getName() + "]");
        return null;
    }

    private XPath createXPath() throws XPathFactoryConfigurationException {
        XPath xPath = XPathFactory.newInstance(XPathConstants.DOM_OBJECT_MODEL).newXPath();
        if (namespaceContext != null) {
            xPath.setNamespaceContext(namespaceContext);
        }
        return xPath;
    }

    @SuppressWarnings("unchecked")
    private NamespaceMapping[] getNamespaceMappings(Class targetInterface) {
        if (targetInterface.isAnnotationPresent(Namespaces.class)) {
            Namespaces namespaces = (Namespaces) targetInterface.getAnnotation(Namespaces.class);
            return namespaces.mappings();

        } else if (targetInterface.isAnnotationPresent(NamespaceMapping.class)) {
            return new NamespaceMapping[]{(NamespaceMapping) targetInterface.getAnnotation(NamespaceMapping.class)};

        }

        return null;
    }

    protected NamespaceContext buildNamespaceContext(final Class targetInterface) {

        final Map<String, String> prefixToUri = new HashMap<String, String>();
        final Map<String, String> uriToPrefix = new HashMap<String, String>();

        NamespaceMapping[] namespaceMappings = getNamespaceMappings(targetInterface);
        if (namespaceMappings != null) {
            for (NamespaceMapping namespaceMapping : namespaceMappings) {
                prefixToUri.put(namespaceMapping.prefix(), namespaceMapping.uri());
                uriToPrefix.put(namespaceMapping.uri(), namespaceMapping.prefix());
            }
        }

        return new NamespaceContext() {
            public String getNamespaceURI(String prefix) {
                return prefixToUri.get(prefix);
            }

            public String getPrefix(String uri) {
                return uriToPrefix.get(uri);
            }

            public Iterator getPrefixes(String uri) {
                String prefix = uriToPrefix.get(uri);
                return (prefix == null) ? null : Collections.singletonList(prefix).iterator();
            }
        };
    }

    protected Object convert(Converter converter, final String returnValue) {
        return converter.convert(returnValue);
    }

    private QName determineExpectedQName(Class<?> returnType) throws XPathExpressionException {

        QName conversionType;
        if (Boolean.class.isAssignableFrom(returnType) || Boolean.TYPE.isAssignableFrom(returnType)) {
            conversionType = XPathConstants.BOOLEAN;
        } else if (Double.class.isAssignableFrom(returnType) || Double.TYPE.isAssignableFrom(returnType)) {
            conversionType = XPathConstants.NUMBER;
        } else if (Node.class.isAssignableFrom(returnType)) {
            conversionType = XPathConstants.NODE;
        } else if (NodeList.class.isAssignableFrom(returnType)) {
            conversionType = XPathConstants.NODESET;
        } else if (String.class.isAssignableFrom(returnType)) {
            conversionType = XPathConstants.STRING;
        } else {
            throw new IllegalArgumentException(returnType + " does not have a mapping to a known XPath return type");
        }

        return conversionType;
    }

    private boolean isSupportedXPathType(Class<?> clazz) {
        return Boolean.class.isAssignableFrom(clazz) || Boolean.TYPE.isAssignableFrom(clazz) ||
                Double.class.isAssignableFrom(clazz) || Double.TYPE.isAssignableFrom(clazz) ||
                Node.class.isAssignableFrom(clazz) || NodeList.class.isAssignableFrom(clazz) ||
                String.class.isAssignableFrom(clazz);
    }
}
